Phase 4 — Security Audit and Wiki Regex
Arc Post-Mortem Summary.
Covered the two security-facing items the v10
to-do left before release: a full security audit (item 31) and an audit and
rewrite of the wiki-page parsing regexes (item 16). Two live-site bugs were
tackled first because they were knocking real servers over: the bulk-email
bot mailbox scan that hung the media updater on pollett.org, and an
out-of-memory crash of the web server on yioop.com. Phase 4.1 (auth, cookie,
and header hardening) and Phase 4.2 landed here; the arc closed
2026-07-10.
Arc started 2026-06-29, after Phase 3.75 closed the authentication
work. This arc covers the two security-facing items the v10 to-do leaves
before release: a full security audit (item 31) and an audit and rewrite
of the wiki-page parsing regexes (item 16). Two live-site bugs are also in
flight and are tackled first, since they are knocking real servers over:
the bulk-email bot mailbox scan that hangs the media updater on
pollett.org, and an out-of-memory crash of the web server on yioop.com.
This is an initial plan; the audit items will gain detail as each is
opened. Marker legend: ✓ done,
▶ next, ? decision
to make, unmarked = planned.
Phase 4 closed 2026-07-10: the security audit and the wiki-parser
rewrite are complete, the last step being the JavaScript help.js port with
per-case JavaScript unit tests. The out-of-memory work logged here is the
defensive clamp; the remaining out-of-memory attention and a separate
pollett.org MailServer.php crash carry into Phase 4.5, along with the
Server Settings split.
Plan
- Run a full security audit before v10 release (item
31). Several strands, some already started in earlier arcs:
- ✓
Crypto.
crawlHash
(Utility.php) is md5 with its halves XOR-folded to
eight bytes and is wired into the binary index formats and 164
callsites, so it cannot be swapped wholesale. A sibling
crawlAuthHash($string) returning
hash_hmac('sha256', $string, AUTH_KEY) was added and
the CSRF token was moved to it in earlier work; the remaining job
is to find and migrate the other security-relevant callsites while
leaving every indexing callsite on crawlHash. In
progress: the audit found six AUTH_KEY-keyed callsites. The
git app-code token, and now the secret-ballot FORM_HASH,
csv_form_hash, and page_hash, are moved to crawlAuthHash
(real HMAC-SHA256); generate and verify were migrated together so
pairs stay consistent, and the ballot verify now compares with
hash_equals. The two user-folder-name hashes are kept on
crawlHash — changing them would rename every
existing user's data folder — but the duplicated
folder-and-prefix computation was pulled into a single
UserModel::userFolderAndPrefix method. AUTH_KEY
generation now comes from a CSPRNG
(base64Hash(random_bytes(AUTH_KEY_NUM_BYTES))) instead
of a hash of the work directory and time. A follow-up removed the
pointless inner crawlHash from the FORM_HASH tamper
check: it now HMACs the page directly
(crawlAuthHash($tmp_page)) so a tampered form must
forge a full SHA-256, not just collide an 8-byte fold. The CSV
form-hash path was strengthened the same way: its content
fingerprint (embedded in the [{form-hash}] placeholder
at save in GroupModel and recomputed at verify in SocialComponent)
moved from the 8-byte crawlHash fold to a full
hash("sha256", ...). Both sides hash the page body
(page_objects holds the parsed body, head split off
separately), so save and verify stay in step; WikiElement still just
HMACs whatever fingerprint is embedded, so it was untouched. Existing
ballot form pages carry the old fingerprint until re-saved, which is
acceptable for the current low-traffic window.
- ✓
Constant-time comparison. The CSRF check in
Controller::checkCSRFToken still compares with
==; switch it to hash_equals so a token
cannot be guessed by timing. Found already done in the earlier
CSRF-to-crawlAuthHash work: the check uses
hash_equals over crawlAuthHash. The
ballot-form-hash comparison touched above was switched to
hash_equals at the same time.
- ✓
Drop dead crypto fallbacks. Now that the PHP
floor is well past 8.0, remove the
mcrypt_create_iv
and mt_rand branches from crawlCrypt.
- ✓
Response and cookie hardening. Confirm the
session cookie sets HttpOnly, Secure, and SameSite, and add a
Content-Security-Policy and
X-Content-Type-Options: nosniff. Done: the
session cookie now sets HttpOnly and
SameSite=Lax (new SAME_SITE_COOKIE config,
threaded through index.php and the atto server's
setCookie/sessionStart, which also now
emits Secure over HTTPS); a
frame-ancestors 'self' CSP and
nosniff were already set centrally in index.php for
every response. The session cookie is the only cookie the codebase
sets, so the sweep covered it all.
- ✓
Input and output paths. Spot-check that request
values pass through
Controller::clean, that view and
element echoes are escaped, and that file uploads are checked for
path traversal, MIME type, and executable content. Spot-check
done: request values go through Controller::clean,
whose file_name type already neutralizes
..; no view or element echoes a raw
$_REQUEST value; upload base names are reduced with
pathinfo BASENAME. One gap closed:
getGroupPageResourcesFolders (the upload write path)
did not strip .. from its sub-path the way its URL
sibling does, so that strip was added. Uploaded files are served by
ResourceController with
readfile/fread
and never included or evaled, so an
uploaded script is downloaded, not run — executable content is
mitigated by how resources are served rather than by an extension
blocklist.
Decided: the cookie and header hardening lands here in
Phase 4.1, not deferred to Phase 5, and the sweep goes as wide as the
codebase allows — every cookie- and header-setting path was
checked, not just one spot.
- Audit and rewrite the wiki-page parsing regexes (item
16). The current wiki markup is parsed by a stack of regular
expressions that are fragile and carry two kinds of risk: crafted
markup that slips script past them (XSS), and patterns that backtrack
catastrophically on hostile input (ReDoS). The job is to build an
adversarial corpus of wiki source that exercises both, then either
harden the individual patterns or replace them with a small tokenizer
and parser. This overlaps with the input/output strand of item 31.
? Decide, when opening this item:
whether to harden the existing regexes in place or move to a tokenizer,
which is the larger but more durable change.
Decided: move to a tokenizer and parser. A block scanner
reads the source into a block tree, an inline lexer runs over each
block's text, and the resulting tree renders to escaped html with a
url-scheme allowlist at the leaves, so nesting is correct to any depth
and the xss and redos classes are closed by construction. The same
design is written twice, in php (WikiParser) and in js
(help.js parseWikiContent, today the weaker of the two), and covers
both the mediawiki-style and the markdown engines; the markdown syntax,
a later student-added bolt-on meant to track github markdown, also
gains its own help page. Opening findings: the nesting failures are
real and demonstrated (a fourth list level renders as a literal star, a
table inside a list item stays literal, a link inside a heading is not
stripped for the table of contents and breaks the heading); the xss and
redos risks, by contrast, were not reproducible with direct probes
(base-address prefixing, a scheme allowlist, entity escaping, and fast
pcre all held), so the durable win here is correct nesting, with the
security classes closed as a structural bonus. First step, in place: a
golden corpus of wiki source captured through today's parser as
per-case reference output, with a parity test, so the rewrite changes
show up page by page as reviewed diffs. The steps, in order:
- ✓ Golden corpus regression net
capturing today's output for both engines.
- Grow the new parser inside WikiParser as parse2 and its helper
methods, beside the old parse so it can be tried on real pages, until
it renders the whole mediawiki syntax. It decodes the entity-encoded
text the controller hands the parser, so it is a drop-in for parse.
The pieces, done and remaining:
- ✓ Paragraphs and blank-line
breaks.
- ✓ Headings with anchor
ids.
- ✓ Horizontal rules.
- ✓ Colon indents.
- ✓ Ordered, unordered, and
mixed lists nesting to any depth.
- ✓ Pre blocks and nowiki
masking.
- ✓ Bold, italic, and
bold-italic emphasis.
- ✓ Links with a url-scheme
allowlist.
- ✓ The allowed inline html
tags.
- ✓ Tables: caption, headers,
cells, and class and style only.
- ✓ Styling templates (center,
left, right, class, id, style) nesting to any depth.
- ✓ Table of contents from the
tree, fixing the link-in-heading case.
- ✓ Named-transclusion templates
({{Main|…}}, {{See also|…}}, Hatnote).
- ✓ Toggle blocks
({{toggle|…}}, {{block|…}}, {{end-block}}).
- ✓ Resources:
((resource:…)) and the chart, CSV, and QR family, passed
through for the component to substitute as the old parser does.
- ✓ Math wrapped for the math
renderer.
- ✓ The reference list, cite tags
to footnotes.
- ✓ Switch parse over to parse2
and delete the old regex engine. Done:
parse's mediawiki branch now runs the tree engine and the old regex
engine is gone (parseMediawiki, processRegexes, makeReferences, the
cite, nowiki, pre, and link callbacks, cleanLinksAndParagraphs, and
the matches and replaces tables with their config — about 490
lines). parse2 and its helpers were renamed to drop the "2".
Markdown stays on the old parseMarkdown until step d; fetchLinks and
the link tables stay. The 13 mediawiki corpus references were
regenerated from the new engine; the 3 markdown references are
unchanged.
- ✓ Add the markdown engine as a
MarkdownParser class, sharing a Parser base only if the code sharing
is real, and give markdown its own help page. Done:
the sharing turned out real, so rather than a
separate class the markdown engine runs on the one cursor-based
WikiParser — both engines share the MarkUpScanner, the block and
inline template readers, the table-of-contents builder, and the
reference and footnote collection. Markdown was filled out to more of
GitHub's flavor (indented code, reference-style links, footnotes, and
the full delimiter-stack emphasis algorithm) and its table of contents
was put on the wiki path's footing. Markdown got its own help page,
Markdown Syntax, mirroring the wiki Syntax page, and the
syntax-summary link now points at it for a group set to the markdown
engine and at the wiki Syntax page for a mediawiki group. Along the way
the
{{search:...}} box was made a parser token, so it is
left alone inside a nowiki example and its form is no longer wrapped in
a paragraph (which was invalid html); the recent-places dropdown was
unwrapped the same way. A follow-up closed a related gap: a
display-time token such as [{recent_places}] shown as an
example inside nowiki or a pre block now has its opening broken so it
stays literal, instead of being drawn as its widget, since those
widget swaps run over the finished html and cannot see that the token
sat inside nowiki.
- Port the finished design to js (help.js parseWikiContent), checked
against the php output on the same corpus. As a
first step the help panel was made draggable by its title bar and
resizable from its corner, since a reader often wants it moved out of
the way of the page beneath it or made larger; the panel switches to
fixed positioning on the first drag and is kept inside the window, and
a press on the close control still closes it. Because the title bar was
made sticky so it stays in view while the help text scrolls, the close
control was moved inside that bar and given an explicit stacking order
above the title so it sits on top of the bar; a sticky bar paints above
a floated sibling, which had hidden the control behind the bar. The
parser port itself is now done: the cursor-based scanner and the
mediawiki engine of the wiki parser were ported to help.js, replacing
the old regular-expression parseWikiContent and parseLists with a thin
wrapper of the same name that drops any head-variable section, parses
the body against this group's read url, and turns the cross-group
markers and resource references the parser leaves into links the way
the wiki view does. It matches the server parser byte for byte on all
thirteen mediawiki corpus references and on the real wiki Syntax
page.
- ✓ Self-host the developer
site: a git-repository wiki page type. Retire
seekquarry.com's old viewgit and MantisBT by
making a wiki page type that turns a page into a browsable, pushable git
repository whose discussion is the issue tracker. Self-contained: the
server reads the bare repo with a pure-PHP object and pack reader and
never runs git; clients clone, pull, and push over dumb WebDAV confined
to the page's resource folder; permissions inherit the group, so read
access allows clone and edit access allows push. Queued behind the audit
and regex items above.
- ✓ Pure-PHP loose-object reader:
refs, and commit, tree, and blob parsing, with size-bounded
inflation and name-verified reads.
- ✓ Pure-PHP packfile reader: index
lookup, offset- and name-based delta resolution, and malformed-pack
bounds.
- ✓ Git page type, with bare-repo
creation on save hardened never to overwrite an existing
repository.
- ✓ Model layer for serving: safe
file lookup, with info/refs and objects/info/packs built on demand
(the reader lists all refs and peels annotated tags).
- ✓ Unit tests for the reader, pack
reader, and model, built entirely in PHP.
- ✓ Read and clone: a git component
on the group controller routes
/<group_id>/<page>.git/... to the model,
checks group read access (a repository is only as cloneable as its
group is readable, gated exactly as the resource controller gates a
file), and serves the bytes. Confirmed end to end: a
real git clone of a repository in the public group pulls
the commit, tree, and blob and checks out; the same repository placed
in a non-public group is refused with not-found.
- ✓ Push: the WebDAV write methods
(OPTIONS, PROPFIND, MKCOL, PUT, MOVE, DELETE, LOCK, UNLOCK) reach the
git component through catch-all routes on the WebSite for those verbs,
with the router's method table, its request-line parser, and its
longest-method length all widened to admit them. A write request must
carry Basic auth whose user owns the group, is root, or has full
member access; reads stay anonymous. Each verb acts on the repository
folder through a confined write path that refuses to climb out with
"..". The PROPFIND reply advertises exclusive write locks so the
client turns on locking, and request bodies are read from the server's
content buffer rather than the input stream, which a long-running
server process does not fill. Verified end to end: a real
git push creates a branch and updates it on a second
push, a fresh clone pulls both commits and their files back, an
unauthenticated or wrong-password write is refused, and the confined
path blocks every traversal. A follow-up made push fast: basic auth
resends the credentials with every object, and the deliberately slow
password hash was being run on all of a push's hundreds of requests
(about a quarter second each, over a minute of hashing for a large
push). A successful check is now remembered in the running server's
memory for GIT_AUTH_CACHE_TIMEOUT, keyed by a hash of the
credentials, so a push hashes once instead of hundreds of times; a
wrong password still pays the full hash, and nothing is written to
disk.
- ✓ Read-mode browser: a git
repository page now opens in read mode as a file browser instead of
reading as a missing page.
- ✓ Clone instructions: the read
view shows the address a visitor clones the repository from. The
clone address is aligned to the end of the line — the right
in a left-to-right locale, the left in a right-to-left one —
so it sits under the clone button that reveals it.
- ✓ Read-view overhaul. The
clone panel now shows the full
git clone
<address> command with a copy-to-clipboard button and
sits above the path. The path drops its last crumb's link (it is
where you already are) and no longer repeats the current file's
name below itself; a download control floats to the end of the
path line that saves just the resource in view — a file as
its raw bytes, a folder as a tar.gz of that subtree. Each commit
and tag row's actions are now icons (browse, diff, tar.gz, zip)
rather than words, and a new diff action shows a commit's
changes: every file it added, removed, or changed with a
line-by-line comparison, built from a new
GitRepository::treeDiff and the existing
diff utility. The Date, Author, Message (and Tag)
headers of the commit and tag list are clickable to sort the
whole bounded history server-side, and a magnifying-glass button
by the clone toggle reveals a search field: on a folder view it
filters the visible tree in the browser, on a commit or tag list
it searches message, author, and date on the server.
- ✓ Read-view follow-up fixes:
the tar.gz row action uses a clamp icon and the commit-diff
action a </> glyph; the branch dropdown is normalized so
its label matches the ref button's size; the path row is hidden
again at the repository root and its text is sized to the file
list rather than smaller.
- ✓ Read-view public-access
fix. Browsing a Git repository page while signed out (or with a
stale form token) sent the folder listing back for every file
and sub-folder link, because the group controller, when it
rebuilds a token-less request from its list of fields that are
safe to keep, was dropping the repo_* fields the read view
steers by. Those read-only navigation fields now sit in that
keep list, so a signed-out visitor browses files, sub folders,
commits, tags, diffs, downloads, sorting, and search exactly as
a signed-in one does. In the same pass the read view reads every
request field it uses once, at the top of each method, instead
of reaching into the request here and there, so it is plain what
a visitor can steer; the clone-command line no longer re-escapes
a value that reaches it already escaped. Separately, formatting
the size of a brand-new zero-length resource no longer logs a
math warning.
- ✓ Git wiki page presentation
pass. On the read view the clone control and its text field were
removed (clone details now live on the edit and source views),
and the download arrow beside a sub-folder path was sized to
match the breadcrumb text. On the edit and source views of a Git
repository page the wiki editing area is replaced by a Git
Controls heading with the repository's clone line; the page
keeps only its settings and page-list icons, and the page
history link is hidden. Ordinary wiki pages are untouched: every
one of these changes is guarded so it applies only when the page
is a Git repository.
- ✓ Git repository push codes.
A person editing a Git repository wiki page is now shown a clone
line that carries their own name and a personal application code,
which stands in for their account password when pushing. The code
is kept in the private database and shown again on each visit; a
refresh control lets them mint a fresh one by giving their
password and choosing how long it should last (a day, week,
month, or year). An expired code is reported rather than shown.
The clone line now reads from the normal side for the writing
direction rather than being pushed to the right, and the copy
control is a clipboard icon rather than the word. A new private
table holds the codes, added to fresh installs and to existing
ones through a database upgrade.
- ✓ Git push code editing
polish. The Git repository edit page now always shows a working
clone line: before a push code exists it shows the plain clone
address together with a note that a code must be made to push,
since making one needs the person's password. The refresh, copy,
and create controls are now icons drawn through the shared icon
helper, using plain characters rather than drawn shapes, and
sized to sit level with the expiry menu beside them. The expiry
choices are now one month, three months, six months, one year,
or never, with one month the shortest.
- ✓ Git access panel styling.
The heading now reads Git Access. The clone address is shown at
the same size as file names in the repository listing, the copy
and refresh controls are square, sit level with the address box,
and have a small gap between them, and the create control is
square and level with the password box. Pressing refresh hides
the cloning-only note straight away. A person whose account has
an empty password can now make a code by leaving the password
box blank.
- ✓ Git push over these codes.
Pushing to a Git repository wiki page now checks the person's
name and the code woven into the clone address against the saved
application code instead of against their account password, so
the addresses shown on the edit page can actually be pushed to. A
valid code is accepted, a wrong one and an expired one are both
refused, and a code set never to expire always passes. The brief
in-memory record of a good check is never allowed to outlive the
code's own expiry. The edit page had left one box open, which
pushed the footer up into the middle of the page; that box is now
closed. The copy, refresh, and create controls are drawn as fixed
square buttons with the character centred, level with the boxes
beside them, rather than relying on aspect ratio. New unit tests
cover saving and reading application codes.
- ✓ Git repository statistics.
A Git repository wiki page now shows, in the Git Access panel on
its edit page, a
short set of statistics: the number of commits and files, the
busiest authors, the commits made each month, and the commonest
file endings, each drawn as a small bar chart. The numbers are
worked out from the repository once for a given newest commit and
saved, so later views reuse them until a new commit changes the
branch tip; both the history walk and the file walk stop after a
generous cap so a very large repository stays quick. New unit
tests cover the counting and the saved-statistics file. The
statistics show on the edit page and, where the public may read
the repository, on the view-source page as well. Two smaller
fixes ride along: every icon button is now drawn through the
shared icon helper as a fixed square with its symbol centred
inside, so the edit-toolbar icons sit level with the access-panel
buttons and no symbol spills its box, and the password box is
lined up with the menu and button beside it.
- ✓ Icon button sizing and the
first step towards an issue tracker. Icon buttons are now 32
pixels square by default rather than the smaller size they had
become; a button that wants the smaller 28 pixel size adds a
"small-icon" class in its icon-helper call, and the symbol inside
is drawn a little larger to suit the roomier button. As the
groundwork for tracking issues against a Git repository wiki page,
a dollar sign is no longer kept in the name of a wiki page a
person creates, reserving that character so a repository's issues
can later live on hidden companion pages named with it.
- ✓ Path and file view: the
default branch's tree lists folders first then files, each a link;
following a folder walks into it with a breadcrumb trail back up,
and following a file shows its contents, with a binary file and an
oversized file (past
MAX_GIT_BLOB_VIEW_LEN) named
rather than dumped. The path is resolved only by matching entries
that exist, so a crafted path cannot escape the tree.
- ✓ Rendered markdown readme: a
README at the current folder is shown below the listing, escaped
first then run through the markdown parser so its own markup
renders but any raw HTML in it stays inert.
- ✓ A branch dropdown to switch
branches: the branch and path bar shows a dropdown of every branch
with the current one selected, and choosing another reloads the
page at that branch's root. The read view is now styled as well,
so the clone line, bar, listing, file view, and README read as a
file browser rather than bare markup.
- ✓ Read-view polish toward the
GitHub look: the file list now sits in a fixed-height box that
scrolls when there are many files rather than stretching the
page. The README has a header bar with a book icon and the word
README and, when it has more than one section, a contents button
that opens an indented menu of its headings; choosing one scrolls
to that section (matched by position, so odd heading characters
cannot break it). A long README is now kept inside its box: words
wrap and wide code or images no longer spill past the edge.
- ✓ Per-file last-commit
column: alongside each file and folder the listing now shows the
summary of the commit that last changed it and how long ago,
found by a bounded newest-first walk of the history (capped by
GIT_LOG_MAX_COMMITS) comparing each folder listing against its
first parent. The middle summary column is hidden on mobile. The
README icons are now plain Unicode glyphs rather than inline SVG.
The shared markdown renderer was also fixed: fenced and inline
code are pulled out and protected before emphasis, headers,
links, the math rule, and paragraph wrapping run, then put back,
so code is escaped exactly once and things like $_GET or a PHP
block are no longer mangled. A commit list and a file filter
follow.
- ✓ Read-view bar tidy and two
fixes: markdown links whose address sat on the line after the
opening parenthesis were being pulled apart, so link and image
addresses are now held to a single line. The clone address now
reads group/<id>/<page>.git to match the wiki page's
own address, and the router accepts that leading "group" segment.
The bar dropped its "Clone this repository:" and "Branch:" labels,
the branch chooser gained a small branch glyph, and the clone
address moved onto its own line that a clone button in the bar
shows or hides. The README contents menu was made sturdier too:
each heading is given a plain id and its menu entry is now an
ordinary link to that heading (no script needed to jump), and one
small helper shows or hides the contents menu and the clone line
by flipping a class.
- ✓ Ref chooser and history
views: a chooser beside the branch shows HEAD, a short commit
name, or a tag, and opens to the recent commits and tags plus
ways into the full Commit List and Tag List. Those lists show
date, author (or tag name), message, and per-row actions
(browse that snapshot, download it), loading twenty rows at a
time and fetching more as the reader scrolls. Picking a commit or
tag shows that snapshot's files, with the chosen point carried
through folder links. A download button offers the shown
snapshot as a zip or a gzip-compressed tar, built in pure PHP to
a temporary file rather than in memory and capped in size, so
archiving even a large repository stays within the server's
means. The bar's branch glyph was dropped and the branch and ref
choosers now sit side by side, the same height, with the folder
path on its own line below (rooted at a "/" link) and a parent
link in the file list when in a sub folder. The clone address,
the git service's own links, and so cloning and pushing all use
the group/ form to match the wiki page's address. The anchor
overshoot that sent both these README headings and the wiki
parser's own table of contents to just past a heading (the fixed
top bar covered it) was fixed by leaving room above a heading it
is scrolled to.
- ✓ Discussion as an issue
tracker. Each issue against a git
repository wiki page lives on a hidden companion page named
<page_name>$<issue_number>, kept out of the group's page
listing the same way personal chat groups are hidden by their
reserved name prefix. A companion page carries no page-head settings
and no wiki text, only a small record of the one issue: who reported
it, who it is assigned to, its priority, and a history of what
happened and when (opened and by whom, assigned and by whom, closed
as fixed or marked won't-fix). The page's own discussion thread is
the issue's discussion. The repository page's discussion button opens
a list of its issues; clicking one opens its detail page, where an
editor changes its status and how urgent it is, a beetle button
starts a new issue, and a search box with a funnel filter finds
issues by status and by keyword.
- ✓ Reserve the dollar sign: a
person can no longer put one in a wiki page name they create, so
the character is free to mark issue companion pages.
- ✓ The model and helpers that
create and read those companion pages and their issue records,
and the group page-list query taught to leave companion pages out
of the listing.
- ✓ The screens: the issue
list off the discussion button, the
detail page opened by clicking an issue, the new-issue form, and
status-and-keyword search.
- ✓ The issues view,
reached by a git page's discussion button, which for these
pages is relabelled the issue tracker. Along the top a search
box stretches across the activity and ends in a
magnifying-glass button and a funnel button whose drop-down
narrows the list by an issue's single status of reported,
assigned, marked fixed, or marked won't fix, by the open group
that gathers reported and assigned or the closed group that
gathers the two marked states, or to the visitor's own
issues. For a signed-in visitor a beetle button
shows or hides an editor-style new-issue form whose fields are
hinted by placeholders rather than labels; the reporter picks
the branch and the version the issue was seen on, current code
being stored as the current commit, but not its urgency, which
starts at the middle. Reporting one stores the issue's record,
title, branch, and version on its hidden companion page and
starts that issue's own discussion thread. Beneath comes the
list, each a bordered row with a narrow number, a title, a
status in words, and when it was last touched, shown as how
long ago while that is under a day and as a date after. Its
rows link into each issue's detail page, and its headers
underline as the pointer passes over them to show they sort.
It starts newest first, any column header re-sorts it, and
once it grows past a set height it scrolls under headers that
stay in place.
- ✓ The issue detail page,
opened by clicking a listed issue.
At the top a back link returns to the list, then the issue
number. The dated list of status changes it has been through
follows, each line naming who made it, so the reporter and
the times are read from the list itself rather than a
separate line. A group editor then gets a status control set
to the current status, without a placeholder: choosing
assigned reveals a username box and choosing marked fixed
reveals a commit box, each with an arrow to send it, while the
other choices send at once. The priority follows the same way
with a low, medium, high drop-down, then the title with a
button to jump to the latest comment and an icon button that
opens the comment box floated onto its line. The issue
description shows as the first comment, and below it a
scrollable box lists that description and every comment since.
Comments are the posts on the issue's own discussion thread
and are written with the same editor group threads use, so a
comment can carry uploaded resources and wiki or markdown
text; each is shown with its writer, time, and rendered
body.
- ✓ Searching and
filtering the issue list. The search box narrows the list as
it is typed in, matching an issue's text and the meta terms
reporter:name, assignee:name, issue:number, status:word, and a
since:date / before:date range,
with a hint beneath the box that names them. The funnel's
drop-down gains an all-issues choice that turns filtering off
and marks the filter in use with a check, and the funnel
button fills in while any status filter other than all is
on.
- ✓
Who may create git pages, and a Server Settings git fieldset.
- ✓ Who may turn a wiki page
into a git repository is governed by a modifier on the Feed and
Wiki activity: a role carrying the no_git_repository modifier
loses the ability. The admin role has the activity without the
modifier and so may create one; the ordinary user role carries
the modifier and so may not. For those users the git repository
choice is left out of the page-type menu and refused if posted
anyway, reverting to the page's current type; the root account
may always create one.
- A git fieldset on the Server Settings page.
- ✓ A default branch name,
default master, that a new git repository page starts its
empty repository on. Saved with the other server settings and
read when the repository is first initialized.
- ✓ Push and blob size
caps, and a maximum repository size that defaults to no
maximum, each read as a human size like 10M and enforced as a
repository is pushed to: an object larger than the blob cap, a
pack larger than the push cap, or a push that would grow the
repository past its maximum is refused. Each cap is chosen
from a dropdown of sizes for project scales from micro to
extra large plus an unlimited choice, and the fieldset links
to a Git Wiki Pages help page.
- ✓ Turn off resource archiving
and versioning for git pages, since git carries its own history. A
VersionManager is now told whether to version when it is built: the
resource operations look up the page's type and, for a git repository
page, build the manager with versioning off, so it creates no archive
folder and takes no version snapshots and git's history is not
duplicated. The decision keys off the page type rather than sniffing
the folder. The lock a version operation would take is skipped when
versioning is off, so a write into such a folder no longer looks for
an archive that is not there.
- ✓ Back-port the WebSite WebDAV
changes to
github.com/cpollett/atto, then add a WebDAV
example there (example 14, renumbering every later example up by one)
and replace the Fiber example (now 27) with one illustrating the use
of fibers for concurrency within an existing server. Done in atto: the
later WebSite changes were carried over, the WebDAV example serves a
folder over the DAV verbs and is gated behind an authenticator, and
the replacement example shows a slow handler yielding with
Fiber::suspend() so the single-process server stays responsive —
the non-blocking machinery this project relied on when it drove
WebSite and MailSite.
Other work
- ✓ Two wiki-link forms that
had regressed were fixed in renderLink. A three-part link
[[relationship|page|shown]] was linking to its first part,
the relationship, rather than to the page; it now links to the middle
page part, which is the same part fetchLinks already records the
relationship against. A [[group@page|shown]] link, meant to
reach a page in another group, was treated as a page named
group@page in the current group; it now emits the
@@group@page@@ marker that WikiElement resolves to that
group's read url at display time, since a library parser cannot look a
group id up. The mw_links corpus reference was regenerated for the
group@page change and the parser test now covers both forms.
- ✓ A markdown link to another
group now works the same way. The markdown reader treated a
[Syntax](Public@Syntax) destination as a plain relative url,
so it never reached the other group. A link destination that names a
group as group@page now becomes the same @@group@page@@
marker the mediawiki path uses, resolved to that group's read url when
the page is shown. Only a link destination is affected: an image's
destination is left as written, so a filename that happens to contain an
at sign is not mistaken for a cross-group reference.
- ✓ The syntax-guide link shown
when a page does not yet exist now points at the right page.
Under a group whose render engine is markdown, the "read about the
syntax" link beneath the create-page form addressed the page as
Markdown Syntax with a space, so the space landed in the url
and the link went nowhere. Wiki page names store a space as an
underscore, and the help page itself is filed as
Markdown_Syntax, so both places that build the link now use
that name. The mediawiki groups' Syntax link has no space
and was already correct.
- ✓ The unused
additional_substitutions path was removed and the wiki search box was
given a working home. Once the wiki parser became
cursor-based, nothing read the additional_substitutions list any more,
so the
{{search:...}} box that rode on it had quietly
stopped working. The list is gone from the parser's constructor, from
setPageName's parameters, and from the tuple SocialComponent threaded it
through; the setPageName callers that passed it (and, where that left a
trailing empty argument, the arguments after it) were adjusted, and a
parser construction argument and an initCommonWikiArrays parameter that
only fed it were dropped. The search box now renders where the other
display-time tokens do, in WikiElement's dynamicSubstitutions: the
{{search:...}} tag is carried through the parse untouched
and swapped for a real search form — built with the site search
address and the icon-link helper's buttons — when the page is
shown.
- ✓ The markdown reader was
filled out to more of GitHub's flavor, and its table of contents was put
on the same footing as the wiki path's. Four things a readme
commonly uses now read correctly: a run of lines indented four spaces is
shown as a code block; a reference-style link such as
[text][name] resolves against a [name]: url
definition given anywhere on the page, even further down than the link
itself; a footnote [^name] becomes a small numbered link
with the note listed at the foot of the page and a link back up to it;
and emphasis is resolved by the full delimiter-stack pass GitHub's
markdown uses — each closing run paired with the nearest earlier
opening run, the rule of three skipping a forbidden pairing, and two
characters taken at a time so a triple run nests emphasis inside strong
— so underscores inside a word (a name written in snake case) and
stars with a space on each side stay plain text while a star inside a
word still emphasizes. The table of contents no longer has a reader of its
own: markdown headings are collected during the scan into the same list
the wiki path fills, the box is built by the same routine, and every
heading is given an id; the old regular-expression table-of-contents
reader and its heading-level id cut-off were removed. New tests cover
each addition.
- ✓ The markdown reader used
for a code repository's readme was rewritten to read its text with a
scanner and a recursive-descent parser in GitHub's flavor of markdown,
and no longer leans on regular expressions. A readme is now
read a block at a time — headings, fenced code, block quotes,
rules, lists, pipe tables, and paragraphs — and the run inside
each block a character at a time. Where a readme uses a wiki
{{…}} template, the reader hands it to the very same
template readers the wiki path uses, so a toggle, a citation, a centered
block, or a wrapping block behaves the same in a readme as on a wiki
page. The old reader's machinery was taken out: the roughly two-hundred
line table of brace regular expressions with the two arrays and build
loop that fed it, the list-run and provided-regex routines, the
table-building callback the old path used, and the now-dead locals and
stale note left in the constructor. The output is meant to match what
GitHub shows and so differs from the old reader by design; the corpus
test's three markdown cases were regenerated to the new output, and that
test now feeds a markdown case its raw source, the way a git readme blob
reaches the reader, rather than cleaning the text first. A new test set
checks headings, emphasis, fenced code, links and images, lists, tables,
block quotes and rules, the shared templates, tag passthrough and
escaping, and that deeply nested markdown returns bounded escaped output.
The extra-substitutions constructor argument, read only by the old
reader, is kept for now but no longer applied.
- ✓ The parser now stops
before it can run the call stack out, and link gathering no longer
leans on regular expressions. Wiki markup can nest — a
block inside a block, a link whose text holds another link — and
each level asks the parser to call itself again. Markup nested past
twenty levels, whether written that way by hand or crafted to make the
parser recurse without end, is now handed back as plain escaped text
instead of going deeper, so a single page can never spend the whole
call stack. Separately, the routine that lists the pages a wiki page
links to used to find those links with four regular expressions; it now
scans for the
[[…]] links and the
{{category…}} tags directly. The pattern tables
that fed the old link search, unused once the scan replaced them, were
taken out, along with a fragment-stripping line in the same routine
that computed a value it never used. The head-variable reader's one
remaining split on a blank line, which never needed a pattern, was
changed to a plain string split. A test feeds the parser deeply nested
blocks and links and confirms it returns bounded escaped output rather
than crashing.
- ✓ The last regular
expressions on the scanner render path are gone. Five spots
that still leaned on a regular expression now read their input a
character at a time. A table's opening attributes are searched for a
trusted class or style by name without regard to case, with spaces
allowed around the equals sign, so the safe pair is lifted out and
anything else dropped. A link target's scheme — the name and
colon that lead an off-site address — is read as a leading letter
followed by letters, digits, and a few marks. The plain inline tags the
parser passes through, such as a bold open or a line break with or
without a trailing slash, are matched by reading the tag name and
checking it against the short allowed list. A block or inline-block
wrapper's opening line is split on its bars into a keyword, an id, and
a style, each checked for the braces and bars it may not hold. And a
page's head section is broken into its separate settings by splitting on
blank lines. Each reader was checked to give the same result as the
expression it replaces, including case, empty values, and the unsafe
tokens that must be turned away.
- ✓ Wiki form runtime fixes: a
clean captcha input tag and a nonce call that runs in time. The
keyword-captcha text field no longer writes itself with a short self
closing tag; like every other form field it now ends with a plain angle
bracket. The small script that starts the browser's proof-of-work
— the findNonce call — used to sit inline in the form body,
where it ran before the hash_captcha.js file that defines findNonce had
loaded, so the page reported findNonce as undefined and, with no nonce
computed, the server turned a correct keyword captcha away as a failed
one. The inline script is gone from the form markup; the call is now
added to the page's foot SCRIPT by setupProofOfWorkViewData, alongside
the scripts it already lists, so it runs after those have loaded. Both
the wiki and the static page paths reach this through the same by
reference setup, and both draw their foot SCRIPT from the web layout.
- ✓ Nowiki inside a pre block,
attributes at a table cell's wall, and rules a reader can add to the
parser. A nowiki span inside a preformatted block — one
made by pre tags or by leading spaces — now has its tags dropped
and its characters kept, and a pre close that falls inside such a span
no longer ends the block early. A safe run of attributes written at a
table cell's wall, before the bar that ends the cell, is kept as that
cell's attributes, with anything outside a short safe list dropped so a
stray word or an event handler cannot ride in. And the parser now takes
rules a caller adds: each is tried before the parser's own handling,
both at the start of a block and at each spot in a line, and the html a
rule returns reaches the reader as html rather than being escaped. The
mediawiki-dump reader uses this to fold its image links, redirects, the
other-uses hatnote, and the many templates it drops into the parse by
scanning rules of its own, so a cached dump page's html comes through as
html; its regex passes before and after the parse are gone. Text that
follows the attributes at a cell's wall is kept as that cell's content
rather than dropped. A form template whose html is block level — a
field, a dropdown, a data block and its close — may be followed on
its line by inline content such as a line break and is still emitted as
its own block, so a dropdown's select and a data block's option list are
no longer split apart by a stray paragraph tag landing inside them.
- ✓ The template dispatch reads
by recursive descent now, not regexes. A small head reader
scans the name, notes the separator, and hands the rest to each form, so
the alignment, class/id/style, see, hatnote, toggle, and cite templates
no longer ride on patterns. A class, id, or style template placed inside
a line of text now wraps just its content in a span, so that styling can
be used inside a table cell or a sentence.
- ✓ Table single-bar cells and
a notoc region. A table row can divide its cells with single
bars, not only double bars, and a bar inside a wiki link or a nowiki
span stays part of its cell rather than dividing it. A notoc region
— the lines between a notoc tag and its close — renders
normally but keeps every heading inside it out of the contents box, so a
stretch of example headings is excluded without marking each one.
- ✓ The wiki form templates are
back, as parser rules rather than regex passes. Every form the
old parser recognized — the sign-in, ballot, session, alias, and
check markers, and the whole csv form-field set (text fields, text
areas, drop-downs, sorters, choose-k, check boxes, radios, submit, and
the timestamp, date, username, and captcha helpers) — is now a
case in the template dispatch, found by the scanner and returning the
same marker or form html verbatim so the group controller's side keeps
working. A standalone form comes out as its own block rather than
wrapped in a paragraph. The mediawiki-dump iterator applies its own
dump-only substitutions before handing a page to the parser rather than
routing them through it.
- ✓ Four more wiki reading
fixes. A table row can now pair a header cell with the data
beside it — a bang cell then a double-bar data cell — and a
cell's text can run across several lines. A single top-level heading,
the page title, is left out of the contents box while two or more are
kept, and a heading can be kept out on its own with a
<notoc> marker. And a line that opens with a space is
shown preformatted, gathered with its neighbors into one pre block.
- ✓ A spreadsheet resource
carries its download link and histogram toggle only when asked
for. An inline resource in a wiki page no longer shows those
two controls by default; adding
!verbose after the name
and any cell range turns them back on, as in
((resource:name.csv##B2#C3!verbose|Description)). When
Yioop shows a single csv as its own resource page, that view requests
!verbose itself so it keeps the controls, and a
secret-ballot page can ask the same way. The flag uses a symbol other
than a colon and is pulled off before the namespace split and the
cell-range hashes, so it clashes with neither.
- ✓ Math and nowiki no longer
escape each other's characters twice. Text that already
carries an escaped entity, such as one an author wrote by hand to show
a tag as an example, keeps its single escaping and shows the tag it
stands for rather than the raw entity text, instead of being escaped a
second time. And content between backticks is handed to the math
renderer whole rather than read for wiki markup, so a matrix written
with brackets draws as a matrix instead of becoming a link.
- ✓ Three wiki behaviors the
scanner rewrite had dropped are back. The contents box is
placed just before a page's first second-level heading, so a lead
paragraph and any top-level heading stay above it rather than being
pushed below. A nowiki marker inside a pre block is treated as a
directive not to read its contents as wiki markup: since a pre already
shows text literally, the marker is dropped and its contents kept, so
example markup shows as plain characters instead of the nowiki tags
themselves appearing. A nowiki span that runs across several lines is
kept whole in the same way, so an example holding markup that would
otherwise open its own blocks — a table, say — is read as
literal text between the markers rather than being parsed. And a run of
definition lines, each a term then a
colon then its meaning, becomes one description list instead of a
paragraph. Guard tests cover each, and the reused contents-placement
helper is the one the markdown path already had.
- ✓ Restyled the table of
contents after the parser switch. The tree engine wraps the
contents in a div with the toc and top-color classes; a .toc rule (the
old border, width, padding, and margin) was added to search.css, which
the web layout already loads, and top-color carries its background from
the appearance theme as before.
- ✓ The MailSite command-loop
no-progress guard now only fires when there was input to consume.
With an empty buffer it read a command that needs no input as a stuck
loop and dropped the connection, which was failing the fiber park and
drain test; the guard predates and is unrelated to the search-loop fix.
- ✓ Fixed two failing unit
tests, one a stale expectation and one a test that depended on the
machine's profile. The parse2 heading tests now expect the
anchor ids the parser emits, which went stale when the table of
contents work gave headings ids. The password-policy test depended on
whatever policy the running profile happened to set, so it passed on
one machine and failed on another; passwordPolicyViolations now takes
an explicit policy, defaulting to the configured one but with the
require-a-character-class flags coerced to real booleans so a profile
that stored them as the strings "true" or "false" can no longer read as
always-on, and the test drives every configuration itself rather than
reading the ambient one.
- ✓ Web mail shows every text
block and draws inline images where they belong. A message that
lays out several text blocks with images between them — the shape
Apple Mail sends when a writer drops screenshots into the flow —
previously showed only its first text block and pushed every image down
into the attachment list. The parser now keeps the parts in the order
they were sent as a body-parts list, treats an image the sender left
inline as inline rather than as an attachment, and reads every text
block instead of only the first; a multipart/alternative group still
shows just its richest form, with the plain-text form kept for replies
and search. The message view walks that list, drawing each text block
and each image — an image as a data URL so it needs no extra
request — in the order the sender wrote them.
- ✓ The mail server no longer
wedges on a search, and it sends over IPv4 so its address has matching
reverse DNS. An IMAP SEARCH evaluated its terms with
$r = $r && evalOne(), which let PHP short-circuit
once a term was false: the token cursor stopped advancing and the
evaluation loop spun at full processor use forever, stalling the whole
event loop and with it all delivery. Both the grouped and top-level
loops now call the evaluator first and fold the result in afterward, so
the cursor always advances and a non-matching message ends the search as
it should. Separately, outbound delivery now binds its socket to the
IPv4 wildcard so it leaves over IPv4: this host's IPv4 address has a
matching reverse-DNS record while its temporary IPv6 autoconfiguration
address does not, and receivers such as Gmail reject mail whose sending
address lacks forward-confirmed reverse DNS.
- ✓ Repository table columns
can be resized, renderable files are drawn, and statistics scroll rather
than crowd. Each column heading of the repository file listing
and of the issue tracker, except the last, wraps its label in the same
resizable box the resource lists and spreadsheets already use, so a
reader can drag a column wider or narrower to suit what they are reading
with no added script; the box fills its heading and will not shrink
past the column's own width, so the drag corner stays at the column's
edge instead of drifting inward, and clicking the heading still sorts the
column. A
repository file the browser can show
directly — a common image, a PDF, or a common audio or video file
— is now drawn inline with an image, PDF, audio, or video tag
instead of being called a binary file, sized so it never forces sideways
scrolling and capped in height. The statistics groups each resolved the
page's default folder rather than following its resource path, so a
repository kept at a resource path showed no statistics at all; they now
follow the redirect the way the rest of the repository views do, and each
group draws every row inside a box tall enough for a useful number of
them — about ten contributors, twenty recent months, ten file
types — that scrolls when there are more, with no control or script
involved.
- ✓ A repository page's
resource path becomes a working repository on demand. When a
git request reaches a repository page, the folder its resource path
points at is now handled by its state rather than assumed to already
hold a repository. An empty folder has a bare repository started in it,
so the first clone or push to a resource path that was just pointed at a
fresh folder works without a separate set-up step, instead of failing
with a not-found error. A folder that already holds a repository is
served as it is, its history untouched. A folder that already holds
other content that is not a repository is left alone and the page's read
view warns that the resource path cannot hold the repository, so the
misconfiguration is visible rather than silent. A GitRepository folder
classifier backs this, with a test covering the three cases.
- ✓ A page's resource path is
honored or reported, never silently ignored. When a wiki page's
resource path (the redirect a person sets to keep that page's resources
in a folder of their choosing) pointed at a folder that did not exist and
could not be made, the resource folder lookup quietly fell back to the
page's own default folder, so resources such as a git repository were
stored where the operator never asked and the misconfiguration went
unseen. The lookup now creates the chosen folder only when it is asked to
and the parent folder exists (respecting the create flag its callers
pass), and otherwise reports the failure instead of using the default
folder. So a person can tell, the read view of any page whose resource
path is set but unreachable shows a warning that the path could not be
reached or created and asks them to correct it in the page settings;
this covers git repository pages and ordinary wiki pages alike.
- ✓ File listing polish and
newest-first commit months. The read repository page's file
listing gained an author column showing who last changed each file
(carried through the cached last-commit lookup from a new authorName
helper), its Age heading is now right-aligned to match its column, its
header row shows a border between columns and stays fixed at the top as
the listing scrolls, the columns were re-proportioned so the message
takes the slack and the rest size to their content, and the message and
author columns fold away on a narrow screen (the issue list and file
listing now share one git-hide-narrow rule for that). On the edit page,
the commits-by-month breakdown now lists the most recent month first
rather than last.
- ✓ Issue tracker opens on an
empty repository. A git repository page whose repository had no
commits yet showed the empty-repository clone notice for every view,
including the issue tracker, so people could not report or discuss issues
until code had been pushed. The read-mode set-up returned early on an
empty repository before it reached the view dispatch; now, when there are
no branches, it still opens the issue tracker if that is the requested
view (its new-issue form simply offers no branch or version to pick),
and only the file views fall back to the empty-repository notice.
- ✓ Git repository page
columns and file dates. The read issues list gained a user
column showing who is answerable for each issue's current status —
its reporter while merely reported, its assignee while assigned, and
whoever last closed it once fixed or won't-fixed — drawn from a new
pure WikiIssue::statusUser with its own unit test, and a priority column
that shows low, medium, and high as one, two, or three exclamations in a
green circle, an amber triangle, and a red stop sign, so urgency reads at
a glance; the user and last-updated columns fold away on a narrow screen.
The file browser, which had no header row at all, gained sortable Name,
Message, and Age headers (the issue list's sort was generalised to serve
any git table, with the age column sorting by its real timestamp), and it
no longer leaves older files blank: the walk that finds the commit which
last touched each file now runs until every file is found rather than
stopping after a fixed number of commits, so a file untouched for years
still shows its date and message, and the removed cap constant is gone;
the result is cached per folder, keyed to the branch tip, so the fuller
walk is only done again when the tip moves. Along the way this fixed a
regression that left a fresh install unusable: a crypto-hardening change
had put AUTH_KEY_NUM_BYTES below Config.php's unconfigured-instance gate
while also using it on the first-run configure path, so
ConfigureTool.php work-dir died before writing a profile;
the define now sits above the gate.
- ✓ MailServer busy-loop
watchdog and guards. On pollett.org the mail server was found
pinned at 99% CPU no longer processing mail, having logged nothing for a
day though its process was alive; since its heartbeat is a repeating
timer fired at the top of every event-loop pass, that silence meant the
loop was wedged inside a single pass, and the climbing memory in its last
lines fit fibers and contexts piling up. MailSite now records when each
pass began and a short note of what it is doing (for example "read
key=5"), and a watchdog installed in listen() uses an asynchronous
SIGALRM — which interrupts a running PHP loop where an ordinary
timer driven by the same stuck loop cannot — to log that note with
a backtrace when a pass runs far too long, so a wedge names its stuck
code even after it stops logging. Two likely causes were guarded as
well: the cooperative file lock, the only fiber suspend point, now gives
up after thirty seconds rather than retrying a never-released lock
forever, and the per-connection command loop drops a connection if
processOne ever claims progress without shrinking its input buffer. The
MailServer heartbeat also now reports the parked-fiber count so the
build-up shows up early. Verified that the watchdog reports a simulated
stall with its activity and backtrace, stays quiet on a healthy pass, and
interrupts a real busy loop mid-run. The same changes still need
mirroring into atto's own copy of MailSite.
- ✓ MantisBT import:
status, attachments, and tests. An imported issue now reflects
its full MantisBT state: an open bug that had a handler is assigned to
the Yioop user that handler maps to, so it shows as assigned rather than
only reported, while resolved and closed bugs keep showing as fixed or
won't-fix. The "Imported from MantisBT #…" footer of source
number, category, version, operating system, priority, and severity is
no longer added, since that already lives in the issue's own fields;
only the original-reporter byline for a bot-folded user remains. Every
attachment in
mantis_bug_file_table is imported too: its
bytes, hex-decoded when the dump stored them that way, are written into
the resource folder of the comment they belong to (or a closing
Attachments comment for files hung off the bug itself), and a wiki
resource reference is added so each shows when the comment is read. The
pure parts of the import — reading the dump's rows, judging a
username, building a body and byline, decoding an attachment, and
writing its reference — were pulled into a new
src/library/DiscussionImport class with unit tests in
tests/DiscussionImportTest.php.
- ✓ Generalized the discussion
importer. The
import-mantis command became
import-discussions with a leading type argument that names
the board. mantis stays the issue-tracking type (dump file,
group, page, locale) and now sits beside the forum and mailing-list feeds
Yioop already imports through Manage Groups: phpbb,
googlegroup, and phorum each take a feed file
saved as RSS or Atom and a group, and hand the work to the social
component's importDiscussions, the same code the web page
uses, so a command-line import matches the web one and the board is read
from the feed.
- ✓ Repository issue tracker
link and importer group membership. On a git repository page's
issue tracker the page name in the breadcrumb is now a link back to the
repository's read view; it stays plain text on the read view itself,
where that link would go nowhere. And the MantisBT importer now makes
every user it maps to a real Yioop account, whether matched to an
existing one or freshly created, a member of the group the repository is
on unless they already belong, so imported reporters and commenters can
reach the issues they took part in.
- ✓ Git repository page
migration fixes. A group of fixes found while preparing
seekquarry for migration, all verified with a live server and a real
git client. The Git Access clone command scrolls horizontally inside its
box rather than overflowing, which it began doing once
crawlAuthHash lengthened the embedded app code; the box was
also given room so its scrollbar no longer clips the text
(.git-clone-cmd uses overflow-x: auto,
white-space: nowrap, a min-height with padding,
and flexes to fill the line). A git page's repository, and its resources
generally, now live at the page's Resource Path when one is set: the
save and the read both follow the resource-path redirect, and the
redirect is written before that folder is resolved so a change of
Resource Path moves the repository with it. The bare repository is made
on any save of a git page that has a resource folder, and since the
maker leaves a folder that already holds a repository untouched,
pointing a Resource Path at an existing server repository (say from an
earlier Apache deployment) adopts it as is. Setting a Resource Path whose
folder does not exist makes it when its parent exists, whatever the page
type, and flashes Resource Not Created when it cannot. And the clone read
check was widened to match the rest of Yioop: a group whose content is
publicly viewable — the "By Request" browse type as well as the
open "Anyone" type — can now be cloned without signing in, while an
invite-only group stays closed; pushing still needs the account's git app
code. The register-type rule was pulled into a small
publiclyReadableGroup helper with a unit test in
tests/GitComponentTest.php.
- ✓ MantisBT issue importer
in GroupWikiTool. A new
import-mantis command reads
a MantisBT bug tracker exported with
mysqldump --no-create-info and turns each bug into an issue
on a git repository wiki page's tracker (for example seekquarry's
yioop-repo). It parses the dump's INSERT rows a character at
a time so quoted text with commas, parentheses, doubled quotes, and
backslash escapes survives. Each MantisBT user is matched to a Yioop user
by username, or, when the name allows, created carrying the MantisBT real
name and email with a random password so the person can take the account
over through the usual reset; a name Yioop cannot take falls back to the
bot-sender account with the original MantisBT name and email kept inline
in the issue body and comment so nothing is lost. The bug's reporter
owns the issue and its notes'
authors own the comments, each dated from MantisBT rather than the moment
of import (createGitIssue and setPageName gained optional date and issue
number parameters for this). When the tracker starts empty the MantisBT
numbers are kept as the Yioop issue numbers, so the last MantisBT number
guides the next Yioop one; otherwise issues take the next free numbers.
Priorities map onto Yioop's three levels and a resolved bug is closed as
fixed or won't-fix, with the source number, category, version, operating
system, priority, and severity kept in the issue text.
- ✓ Pull atto's WebSite
changes into Yioop. The atto web server file
src/library/atto_servers/WebSite.php is kept in step with
github.com/cpollett/atto. Since this arc's cookie-hardening
patch touched that file, atto's newer changes were pulled in at the same
time: the CalDAV verbs (Calendaring Extensions to WebDAV, RFC 4791, the
calendar-sync protocol) — MKCALENDAR and REPORT added to the route
table and both request-line method patterns, with the longest-method
length raised from 9 to 10 since MKCALENDAR is ten characters. Yioop
registers no CalDAV handlers yet, so those verbs are routable but
unanswered; normal requests and the existing git WebDAV verbs are
unaffected. The cookie hardening in this same file still needs carrying
back the other way to atto.
- ✓ Undefined MAX_IO_LEN
warning during unit tests. The atto web server reads a
server setting named MAX_IO_LEN, the largest number of bytes it moves
between a socket and memory in one pass, when it sends or receives a
response. The full set of server settings is filled in only once the
server starts listening, so code that ran before that, as it does under
the test set-up, found the setting missing and logged a warning for
every request. The default is now also placed on the server object the
moment it is created, from a single named constant used in both places,
so the setting always exists.
- ✓ Git bar branch and ref
dropdowns showed in different sizes. The branch chooser's own
style set its text to a small fixed size, but a broader activity-area
rule for every drop-down set a much larger size and, being the more
specific of the two, won. The branch chooser's rule is now tied to the
activity area as well so it is specific enough to hold its small size,
matching the neighbouring ref button.
- ✓ Issue-page polish.
Reporting an issue was sending the reader to the repository's file view
instead of back to the issue list, because the address the redirect used
had been prepared for a web page, with its ampersands written out, so the
view part was lost; the redirect now uses the plain address. The filter
drop-down and the read-me table-of-contents drop-down no longer show their
entries as blue underlined links but in the quiet grey of the resource
menus elsewhere. The report button and the branch and version choosers on
the form now read at the same small size as the branch and ref controls
on the bar.
- ✓ BulkEmailJob bot-mailbox
scan hangs the media updater on pollett.org.
BulkEmailJob::processBotMailbox handles
List-Unsubscribe mailto replies: a recipient who clicks
the unsubscribe link sends mail to the bot address with the subject
“unsubscribe <token>”, the job reads those, acts on
the token, and deletes them. The trouble was the scan. It listed
every message in the mailbox and, for each one, fetched and
parsed the full header before checking whether the message was even an
unsubscribe reply. On pollett.org the mailbox it reads is the root
account's INBOX — the bot address is delivered there as an alias
— and that INBOX is a personal mailbox with roughly ninety
thousand messages, almost none of which are bot mail. Parsing ninety
thousand headers on every media-updater pass is what hung it.
Done. processBotMailbox now narrows the INBOX
through the folder's search index (subject, from, to) and parses
headers only for those candidates, so the root account's unrelated
mail is never opened; the bot reaches root because
MAIL_SENDER / MAIL_REPLY_TO is the bot
address delivered as a root alias, and searching root efficiently was
chosen over giving the bot its own mailbox. The
FileMailStorage folder-index cache also gained a bounded
least-recently-used limit (MAX_CACHED_FOLDER_INDEXES) so
the long-running mail server cannot keep every index it ever touches;
note this is a robustness fix, not the yioop.com out-of-memory cause,
which that server does not exercise.
- ✓ Web server ran out of
memory on yioop.com. The long-running
WebSite
server sits near a high baseline (about 1.7 GB of in-memory index
right after a restart, drifting to a roughly 1.95 GB plateau as the
caches fill) with little headroom under the 2 GB limit, and a single
oversized allocation then tips it over. The root is a corrupt index: a
garbage dictionary length made
IndexDocumentBundle::getPostingsString try to read hundreds
of megabytes in one fread (which pre-allocates the whole
length). The crash diagnostics named both a bot-driven wiki-history
request and, later, a spam search query as triggers — any path that
reads postings. The work below keeps a corrupt index from taking the live
server down; preventing the corruption is the real fix, tracked as a
crawling item (todo 73).
- ✓ Crash diagnostics: a
response-completion log and a fatal shutdown handler that record the
request in flight and the peak memory, which named the culprit
requests.
- ✓
Clamp each posting-string read to the
partition file size, and cap both it and the separate positions
read at a configurable maximum (
MAX_POSTING_READ_LEN,
default 32 MB): a length past that is logged and skipped, so a
corrupt entry cannot over-allocate.
- ✓ Scale the three index caches
from the memory limit (
cacheEntryLimit): about half their
old size at 2 GB, and proportionally more or less on a roomier or
tighter server.
- ✓ Cache instrumentation: the
periodic memory log now reports each cache's entry count and
approximate bytes and the largest posting read seen.
- ✓ Idle-session reaper: drop
sessions idle past a timeout, closing an unbounded growth path.
- ✓ Large-response log: record any
response over a threshold, to catch a single giant reply.
- ✓ Streamed-response access log: a
reply whose body is streamed (a video range, a large download) never
passed through the response-size accounting, so it was missing from
the access log or recorded as zero bytes. The streaming entry point
now writes its own access line, reading the status and length from the
headers the route set before it began streaming, with a guard so the
same response is not also logged with a zero length. A streamed
reply's access line carries its status and byte span; a separate
report that some 206s still do not appear is tracked below. This
also confirmed resource streaming is memory-bounded (a range serves
at most an 8 MB span in 256 KB blocks), ruling a large
media file out as the oversized allocation and leaving the
corrupt-index posting read as the cause.
- ✓ Protocol tag in the access
log: each access line — both the arrival line and the
companion completion line — now carries a short protocol tag
(h1, h2, or h3) right after the request path, read from the
protocol the server recorded for the request being served. This
makes it visible at a glance how each client connected, and in
particular whether a given large-file request arrived over h1, h2,
or h3. A first version read a transient streaming field that was
sometimes cleared by the time the line was written, so it
misreported some h2 requests as h1; it now reads the per-request
SERVER_PROTOCOL the dispatcher stamps, which is stable for the
whole request. The listening port is now appended as well
(protocol:port, e.g. h2:443 or h1:80), so a line shows both how
the client connected and which port it reached — the datum
needed to tell an h1-over-TLS request on 443 apart from a plain
request on 80.
- ✓ HTTP/2 never negotiated over
the secure launcher: the auto-built server TLS context carried the
certificate and key but no
alpn_protocols, so the
listening socket advertised no ALPN and every browser fell back to
HTTP/1.1 even with HTTP/2 checked in Server Settings. The context
now advertises the configured ALPN_PROTOCOLS list (h2
plus http/1.1 by default), so the client can negotiate HTTP/2. The
server already detects the chosen protocol from the client's first
decrypted bytes, so no other change was needed.
- ? HTTP/2 offered inconsistently
per connection: with ALPN advertised on the listening socket, a
page still fetches the document and images over h2 but the css,
scripts, and favicon over h1, deterministically. A first attempt
set the protocol list on each accepted socket as well, on the
theory that inheritance from the listening socket was unreliable;
it made no difference and was backed out, since server-side ALPN
is registered on the listening socket's context and a per-accepted
set is at best a no-op. Also ruled out: the css/script/image URLs
are all root-relative through SHORT_BASE_URL, so same origin and
port, not a scheme or port split; and the h1-to-h2 header
conversion is correct on all three paths (it strips the
connection-specific headers h2 forbids and lowercases names), so a
stray h1 header is not resetting streams. The remaining suspect is
the browser's connection use during the first load rather than a
server misconfiguration; confirming it needs the listening port
and a connection identifier per access line to see whether the h1
resources reuse a warm connection or open new ones.
- ✓ Wiki save failed when a
resource was uploaded with the page: saving a page that also takes
a dropped file re-saves the page once more after the upload so the
resource is parsed in, and both saves land in the same second.
Page history is keyed by page and whole-second timestamp, so the
second write hit a duplicate-key error and the save failed. Both
history writers now go through one helper that clears any row
already at that page and second before inserting, so the later
save replaces the earlier one instead of colliding.
- ✓ Bounded wiki history diff: the
page-history view re-parses a stored version and, when two versions
are compared, runs a longest-common-subsequence diff. Measuring the
real “Syntax” page (about 1150 lines) showed the re-parse
is cheap (about 5 ms, negligible memory), but the diff builds a
table with one cell per pair of differing lines, so its memory grows
with the product of the two line counts: about 82 MB here, and
around 230 MB near 2000 lines, which matches the reported crash
allocation. The diff now skips that table and reports the differing
region coarsely once the cell count would pass
MAX_DIFF_LCS_CELLS (about 15 MB), so a whole-page
diff of a large page can no longer over-allocate. A localized edit is
unchanged, still getting the exact line-by-line diff.
- ✓ Outbound-buffer instrumentation:
the crashes keep being reported at a
set_error_handler
line in the response drain, which cannot itself allocate the
hundreds of megabytes the fatal names, and the existing
per-response large-body log stays silent, so no single response is
the culprit. Under HTTP/2 many stream responses are appended into
one per-connection buffer, so a slow-draining client can accumulate
a huge buffer that nothing currently reports. Added
logOutboundBuffer, called at the enqueue point, which
logs once (with the chunk just added and the request) when a
connection's outbound buffer first crosses the large-response
threshold. This tells us whether the memory goes into an
accumulating buffer or somewhere else before deciding the fix.
Result: the probe stayed silent, the large-response log stayed
silent, and the crash kept landing on the
set_error_handler line — the second branch above,
which pointed away from the send side and into the request-build
path, and on a closer look at that line, into
set_error_handler itself.
- ✓ Root cause and fix — a
set_error_handler stack leak. Each
set_error_handler(null) …
set_error_handler($custom)
block pushes two handlers onto PHP's internal error-handler stack
and pops neither; the restore is meant to be
restore_error_handler(), which pops. The response drain
runs one such block for every writable stream on every event-loop
tick, so on a busy long-running process the internal stack grows
without bound — this is the steady climb in the memory log
— until a push has to grow the stack in one contiguous block,
which is the hundreds of megabytes the fatal names, always at the
set_error_handler line, on whatever URL happened to be
draining. Measured directly: the leaky pair grows about 78 MB
per two million iterations, the balanced pair is flat. Fixed the
eight unbalanced sites in WebSite.php (drain loop,
streamed write and its stall wait, cookie expiry, GOAWAY and
RST_STREAM writes, socket shutdown) to use
restore_error_handler(); the post-TLS reinstall keeps a
single set, since it re-asserts the handler
stream_socket_enable_crypto clears and so pushes once
per connection, far below the drain rate. The same idiom leaks in
in-process controllers (ResourceController,
SocialComponent, CrawlComponent) and in the
Fetcher and QueueServer daemons; sweeping
those is a follow-up.
- ✓ Swept the same idiom across
the rest of Yioop: converted 82 null-suppress restores in 41 files
(controllers, models, media jobs, document processors, index and
mail libraries) plus three
try/finally
capture-handler blocks to restore_error_handler(), so
every suppress block is balanced. Left the one-time boot installs
(index.php, ClassifierTool) and two
deliberately unpaired asserts (TestsController's
test-mode handler and a SocialComponent feed
re-assert) for a separate pass, now done below. The same fix is
still owed upstream in the atto library.
- ✓ Removed those two remaining
unpaired asserts.
TestsController::processRequest
installed a throw-on-error test handler then immediately reset it
to the default, so the install never took effect and both calls
leaked; the test runners have no try/catch to survive a throwing
handler, so the method now runs under the inherited global handler
like any controller and the now-unused handler function was
removed. SocialComponent::importDiscussions
re-asserted the yioop handler after its parse loop although nothing
in the function had suppressed it; with every suppress block now
balanced the re-assert is redundant and was dropped.
- ✓ Large downloads over H2
truncated. A range request for a large PDF (and the no-range
large-file path) served only a small first span as a 206 and then
stopped; Firefox and Safari both got that stub and never fetched
the remainder, so the download hung. The logging half was fine (a
direct test confirmed
stream() fires the response
logger with the 206 status and length). The cause was that both
resource paths capped the served span to the peer's per-stream
flow-control window. That cap could never work: a browser enlarges
its stream window with a WINDOW_UPDATE that is a separate frame
processed just after the request that opened the stream, so at the
moment the route sized the span only the small initial window was
visible — the served spans matched each browser's initial
window exactly (Firefox 128 KB, Safari 2 MB). And naming
a shorter span than the client asked for made the browsers treat
the reply as a partial transfer and fail rather than fetch the
rest. The window cap and the fixed 8 MB per-response cap were
both removed: serveRangeRequest now serves the whole
requested range and the no-range path streams the whole file as a
200, letting the atto server pace the body to the flow-control
credit the client returns as it reads (the Phase 3 fix that
reads the WINDOW_UPDATE alongside the request headers). The pacing
keeps the transfer memory-bounded and interleaved with other
streams, which is what the caps were trying to do by hand. A first
attempt to keep a conservative cap but read the current granted
window failed for the same timing reason, so
peerStreamWindow() and
MAX_RANGE_STREAM_LEN are now removed as unused. The
earlier note here that blamed HTTP/3 was wrong; H3 is off on the
affected servers, and the split there was a self-signed-cert
artifact.
- Prevent the corruption at the source: crash-safe index writes and a
quick, safe recovery if the queue server or dictionary process is
killed mid-crawl (todo 73).
- ✓ A helper for saying how
long ago. The code that turns a moment into words like five
minutes ago had grown up inside the git repository component, yet it
needs no git and its wording is translatable, so it now lives in a small
Timeformatter helper as a timeAgo method that both the commit list and
the issue list call. Its phrases were renamed to belong to that helper,
and each is now looked up under a plain name of its own rather than one
chosen at run time, so that every translatable phrase can be found by
reading the code.
- ✓ A multibyte character in
wiki inline text is no longer dropped. Where the inline reader
met a character it had no special handling for, it escaped one byte at a
time; for a multibyte utf-8 character such as an accented letter or an
emoji that handed htmlspecialchars a lone byte, which under the utf-8
charset returns the empty string, so the character vanished. The reader
now reads the whole character, one to four bytes as its lead byte says,
and escapes it as a unit, so the character survives. This also let the
javascript help-panel port match the server byte for byte, since the
port reads by code point and had kept such characters all along.
- ✓ A code block, and text
left after a shown-as-typed block, now read the way an author
expects. The reader only recognised the pre tag for a block
shown exactly as typed, so a code tag used the same way was escaped and
its closing tag was left on the page as stray text, and a user-agent
example laid out over several lines was torn apart with its indented
line read as a block of its own. The reader now treats a code block the
same as a pre block, reading from the opening tag through the closing
one however many lines it spans, so a pre or code tag inside stays
literal and a line break inside no longer breaks the block. A related
case is fixed at the same time: an indented run of text is read as a
shown-as-typed block only when it starts a line, so text left after a
pre or code close on the same line becomes an ordinary paragraph rather
than a spurious second block. The change is made in both the server
parser and the javascript help-panel port, which stay byte for byte in
step, and each gains a test for the reported inputs. The server parser
gets a case in tests/WikiParserTest.php; the javascript port gets a
JavascriptUnitTest in tests/WikiParserJavascriptTest.php that shows a
pass or fail table in a browser and, from the command line, replays the
same check under node. To make that command-line path work,
JavascriptUnitTest now looks for node and, when it is installed, runs a
javascript test case through it, so the existing Sha1 javascript test
runs from the command line too rather than being skipped. Each case
reports its own sub-results through a shared reporter,
scripts/javascript_unit_test.js, built on the basic.js page helpers, so
a group of checks shows as a count such as 6/6 instead of a single line
both on the command line and in the browser, and the test page lists
each javascript test method as its own link the way the php test page
already does, so a single method can be opened and shared by url.
- ✓ CodeTool search shows
match context.
CodeTool.php search printed only
the line number and the raw matched text. It now prints the line
number and a short snippet — up to thirty characters of file
text on each side of the match (newlines flattened to spaces), the
match itself in bold, bracketed by ellipses — so a hit reads in
context. The bold uses terminal escape codes only when writing to an
interactive terminal that supports them (turning the codes on for a
modern Windows console), and falls back to plain text when the output
is redirected or unsupported, so it works on macOS, Linux, and
Windows without leaving stray control characters in piped output.
- ✓ Wiki page-type setting
tweaks. Settings controls that have no effect for a page type
are now hidden for it — the table-of-contents checkbox is hidden
when the type is Media List — using the existing show/hide
handler, with room to add more control/type pairs. A cut-and-paste
comment in
HeaderElement that referred to the footer was
corrected to the header. A new Media-List-only control, shown only to
the root account, lets root mark a media list to show an index page by
default: a checkbox (off by default) that reveals a file field
(default index.html); when set, viewing the list in read
mode serves that file's bytes directly, so the page renders as the file
itself with no wiki markup around it, while edit mode still shows the
folder so resources can be managed. Only the page's top folder is
replaced, so sub-folders still browse normally.
- ✓ Media-list index page now
served directly rather than framed. The first attempt embedded
the index file in an inline frame, which showed wiki chrome around a
doubly nested page. The read view now emits just the index file from the
controller, the way the resource controller serves a file, and stops, so
nothing but the file is sent.
- ✓ Editors get a way back to
editing the served index page. When a visitor who is allowed
to edit the page views an HTML index page, a fixed edit button is
overlaid in the top opposite corner that opens the page's edit view;
choosing to serve an index in the first place remains a root-only
setting. Every other visitor receives the file exactly as stored with
nothing added. Serving the page also clears any pending one-time
message, so a leftover notice (such as the sign-in confirmation) no
longer surfaces on the edit page reached from the button.
- ✓ LDAP-not-ready state is
now remembered across a restart. Choosing LDAP in the
Authentication panel without filling every field used to save the
method as plain LDAP, so after the web server was stopped and started
the site believed LDAP was active and tried to reach a directory it
could not use, logging "Can't contact LDAP server" on each
request. A new pending authentication value is now stored when LDAP is
chosen but its settings are incomplete; sign-in treats only the plain
LDAP value as active, so the site keeps using locally stored passwords
until the settings are valid, while the panel still shows LDAP selected
and lists what to fix. Saving once the settings are valid promotes the
pending value to active LDAP.
- ✓ Turning LDAP on now checks
that the root account can actually sign in to the directory first.
The Authentication panel gained a root directory username and password
(the password is used only for this check and never stored). When the
method is switched into LDAP, the site binds to the directory as root,
reads the email the directory holds for that account, and confirms it
matches the root account's email here before going live; if the bind
fails or the emails differ, it stays pending on local passwords and says
which check failed. A re-save while LDAP is already active does not ask
again. The required-field asterisks in the panel were also moved to sit
after each control rather than beside the label.
- ✓ Authentication panel
polish. The required-field stars now use the larger, padded
style used elsewhere in Yioop and appear only after a save that could
not turn LDAP on, beside the specific fields that held it up; the
servers star also clears as soon as at least one server is in the list.
The root sign-in labels were shortened to Root Username and Root
Password, leaning on the help page for the detail.
- ✓ Issues stay out of the
group feed. Reporting an issue quietly starts a discussion
thread to hold its comments, and that thread had been surfacing as an
ordinary group post: it showed as a group's most recent post and was
counted among its posts and threads on the account and activity screens.
The feed, the post and thread counts, and the most recent post now leave
out any thread that belongs to an issue companion page, recognised by the
reserved separator in the page title.
- ✓ Shorter back link on an
issue. The link at the top of an issue's detail page now reads
simply Back rather than Back to issues.
- ✓ Even send arrows on issue
edits. The arrow that sends a typed assignee or fix commit now
stands the same height as the box beside it.
- ✓ Real editor for issue
comments. Issue comments are now the posts on the issue's own
discussion thread, written with the same wiki editor group threads use,
so a comment can carry uploaded resources and wiki or markdown text and
is shown with its rendered body. The comment box, the jump-to-latest
button, and the write button moved onto the issue title's line, and the
Comments label was dropped.
- ✓ Resource button group made
its own class. An earlier icon-sizing change had pinned the
shared media-buttons container to a single icon's width, squashing the
view-toggle group on the resource list. That container is left as is and
a reusable icon-button-group class now holds such groups at their natural
width, matching the height of the Go and sort controls beside them.
- ✓ Issue detail, comment
editor, and editor toolbar fixes. A reader who is not logged in
can now open an issue on a public repository and read it: requests with
no form token are trimmed to a known set of fields, and the issue number
and status-filter fields had been left off that set, so they are now
kept alongside the other repository fields. The comment box sets up its
upload handler, editing toolbar, and hidden-until-asked state from the
page's own start-up script, in the order the group feed uses, rather
than through a later step a busier page could skip; the box stays closed
until the comment button is pressed, so the comments are what a reader
sees first. The same icon-sizing change that squared the media buttons
had also pinned every wiki editor button to that square, since the
editor toolbar wraps each button in the shared icon-button container;
that left the toolbar too wide to sit on one row and drew the alignment
buttons wrongly, so editor toolbar buttons are now held at the size and
block layout they had before that change, which the media buttons keep.
The issue search box reads “Search Issues” and the issue
number sits at the far end of its line opposite the back link.
- ✓ Comment box width and wide
uploads. The issue comment box had no width of its own and fell
back to a browser's narrow default; it now spans the width of the detail
panel like the page editor does. An image uploaded into a comment that is
wider than the panel no longer makes the comment scroll sideways, since
images in a comment body are held to the panel's width.